# 登录注册功能实现
30 秒速记
- 分层:路由 →
controller(收参数、写session、决定响应)→service(校验、业务规则)→DAO(只管SQL) - 密码用
bcrypt/scrypt/Argon2慢哈希存,绝不明文、绝不MD5,登录时比哈希不比明文 - 查重靠数据库唯一索引兜底,代码里先查再插有并发窗口
- 登录失败统一提示「用户名或密码错误」,不暴露账号是否存在,再加限流
- 登录成功换一个新的
session id再写用户信息,防会话固定
登录注册说白了就三件事:注册时把账号安全地存进库,登录时校验身份,校验通过后建立登录态。 这套代码按路由、controller、service、DAO 分层,好处是 SQL 只出现在 DAO,校验规则只在 service,controller 只管请求和响应。安全上我最先看三点:密码必须用 bcrypt 这类慢哈希存,SQL 必须参数化,登录失败的提示不能让人猜出账号是否存在。这三条任何一条没做,都不能上线。
理解「登录注册功能实现」时,要先明确输入、运行阶段和可观察输出。这里的交互实验会分别执行正常路径与失败路径,并把 Node.js 的关键状态变化和同屏代码对应起来;不能只记命令名称。
工程中还要验证退出码、资源清理和异常输入。属于 module 场景的配置应保持最小可复现,并在开发环境与 CI 中使用相同命令复跑,避免把平台差异或隐式默认值带到线上。
登录注册看起来是 CRUD,其实是整个系统里安全要求最高的一块。把这节的代码串起来,一次注册的流程是:
- 路由
POST /user/signUp.json交给controller.signUp service.validatorSignUp校验用户名、邮箱、密码格式和两次密码是否一致service.getExistOne查用户名或邮箱是否已存在service.create调DAO写入user_info表
登录则是查用户、比密码、写 session、跳转。
把它做成能上线的版本,有几处必须补上:
const bcrypt = require('bcrypt')
// 注册:只存哈希
const hash = await bcrypt.hash(formData.password, 10)
await userModel.create({ name: formData.userName, email: formData.email, password: hash })
// 登录:按用户名查出哈希,再比对
const user = await userModel.getOneByUserName(formData.userName)
const ok = user && await bcrypt.compare(formData.password, user.password)
if (!ok) {
ctx.body = { success: false, message: '用户名或密码错误' } // 不区分账号不存在还是密码错
return
}
数据库层给 name 和 email 都加唯一索引,插入时捕获 ER_DUP_ENTRY 转成「已存在」的提示,这样并发注册也不会出现重复账号。SQL 一律用 ? 占位符传参,不拼字符串。登录成功后先轮换 session id 再写用户信息,退出时整个销毁 session。这几条做全了,才算一个合格的登录注册。
面试官追问
追问 1密码存成 MD5 加个固定 salt,不是也看不出明文吗?
MD5 太快了,一张显卡每秒能算上百亿次,加固定 salt 也只是换一张彩虹表。要用 bcrypt 这种故意算得慢、每个用户随机 salt 的算法,bcrypt.hash(pwd, 10) 存,bcrypt.compare(pwd, hash) 验。
追问 2注册提示「该邮箱已注册」,安全说这能被拿来枚举账号,怎么处理?
登录接口必须统一成「用户名或密码错误」。注册接口很难完全不提示,常见做法是改成「已向该邮箱发送确认邮件」,邮箱已注册就发一封提醒邮件,接口返回始终一样,再配合验证码和按 IP 限流。
追问 3有人一分钟内对同一个账号试了几百次密码,接口怎么防?
按账号和 IP 两个维度限流,比如同一账号连续失败 5 次锁 15 分钟或者要求验证码。只按 IP 限挡不住分布式撞库,只按账号限又可能被人恶意锁号,两个维度要一起用。
追问 4登录注册这几个接口,你会怎么写测试?
用 supertest 直接打 Koa 应用,把注册成功、用户名重复、格式非法、密码错误、登录后访问受保护页这几条路径都跑一遍,断言状态码、返回结构和 Set-Cookie。比单测 service 函数更能发现中间件顺序、session 没写进去这类问题。
# 登录注册功能实现
# 用户模型dao操作
30 秒速记
DAO只做一件事:把数据库操作包成方法,上层拿到的是一条记录或null- 用模板字符串把用户输入拼进
SQL=SQL注入,一律用?占位符 - 登录不要
WHERE password = 明文,按用户名查出哈希,交给service用bcrypt.compare比 - 按用户名查资料的方法一定要带
WHERE name = ?,漏了就永远返回表里第一个人 - 查重除了代码查,
name/email要建唯一索引;公开接口别SELECT *
用户 DAO 就是把建用户、查重、登录查询、查资料这几个数据库操作封装成方法,上层不用关心 SQL 怎么写。 写 DAO 最要紧的是参数化,用户输入只能通过 ? 占位符传给驱动,不能拼进字符串,不然一个带引号的用户名就能改掉整条 SQL 的意思。另外按用户名查询时,WHERE 条件一定要真的带上,字段也按需列出来,别图省事 SELECT * 把密码哈希一路带到上层。
/**
* 数据库创建用户
* @param {object} model 用户数据模型
* @return {object} mysql执行结果
*/
async create ( model ) {
let result = await dbUtils.insertData( 'user_info', model )
return result
},
/**
* 查找一个存在用户的数据
* @param {obejct} options 查找条件参数
* @return {object|null} 查找结果
*/
async getExistOne(options ) {
let _sql = `
SELECT * from user_info
where email="${options.email}" or name="${options.name}"
limit 1`
let result = await dbUtils.query( _sql )
if ( Array.isArray(result) && result.length > 0 ) {
result = result[0]
} else {
result = null
}
return result
},
/**
* 根据用户名和密码查找用户
* @param {object} options 用户名密码对象
* @return {object|null} 查找结果
*/
async getOneByUserNameAndPassword( options ) {
let _sql = `
SELECT * from user_info
where password="${options.password}" and name="${options.name}"
limit 1`
let result = await dbUtils.query( _sql )
if ( Array.isArray(result) && result.length > 0 ) {
result = result[0]
} else {
result = null
}
return result
},
/**
* 根据用户名查找用户信息
* @param {string} userName 用户账号名称
* @return {object|null} 查找结果
*/
async getUserInfoByUserName( userName ) {
let result = await dbUtils.select(
'user_info',
['id', 'email', 'name', 'detail_info', 'create_time', 'modified_time', 'modified_time' ])
if ( Array.isArray(result) && result.length > 0 ) {
result = result[0]
} else {
result = null
}
return result
},
面试官追问
追问 1用户名填 " or "1"="1,getExistOne 会怎样?
拼出来是 where email="" or name="" or "1"="1",条件恒真,直接返回表里第一条记录,注册永远提示已存在;登录那条拼接更危险,能绕过密码。改成
